Fix/cloud sync reliability overhaul - #196
Closed
EierKopZA wants to merge 4 commits into
Closed
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR strengthens cross-device cloud sync reliability and fixes several state-restore bugs (continue watching reappearing after dismissal, season-watched revert, stale watched cache after restore). It adds retry logic to cloud pushes, broadens realtime subscriptions, and ensures critical caches are warm before reads.
Changes:
- Add multi-layer retry/backoff for
pushToCloud()(in-repo and in coordinator), plus a foreground-resume retry of dirty pushes. - Pull cloud snapshot before refreshing Continue Watching, subscribe to
INSERT(not justUPDATE) onaccount_sync_state, and re-init the watched cache after a full restore. - Re-order watched-history removal vs. Supabase writes in
markSeasonWatchedand pre-warm the watched cache before season-progress fetch in details.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt | Adds activity lifecycle callbacks to retry dirty pushes when the app returns to foreground. |
| app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncCoordinator.kt | Adds backoff retry loop around pushToCloud() before marking state dirty. |
| app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt | Adds internal retry loop for pushToCloud() and re-initializes watched cache after full cloud restore. |
| app/src/main/kotlin/com/arflix/tv/data/repository/RealtimeSyncManager.kt | Subscribes to INSERT events on account_sync_state in addition to UPDATE. |
| app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt | Pre-warms watched cache before season-progress fetch and reorders history removal before Supabase writes. |
| app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt | Pulls cloud snapshot before refreshing Continue Watching to avoid resurrecting dismissed items. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+76
to
+83
| for ((retryIndex, retryDelay) in RETRY_DELAYS_MS.withIndex()) { | ||
| if (retryIndex > 0) { | ||
| delay(retryDelay) | ||
| } | ||
| val result = runCatching { cloudSyncRepository.pushToCloud() } | ||
| if (result.isSuccess) return@launch | ||
| Log.w(TAG, "Push attempt ${retryIndex + 1} failed after ${invalidation.scope}: ${result.exceptionOrNull()?.message}") | ||
| } |
Comment on lines
+492
to
+503
| // Build payload fresh each attempt in case state changed during retry gap | ||
| val payload = runCatching { buildCloudSnapshotJson() }.getOrElse { | ||
| lastError = it | ||
| if (attempt < maxAttempts) { | ||
| AppLogger.breadcrumb( | ||
| tag = "CloudSync", | ||
| message = "push_build_attempt=${attempt}_failed", | ||
| severity = "warning" | ||
| ) | ||
| delay(retryDelayMs) | ||
| continue | ||
| } |
Comment on lines
+487
to
+488
| val maxAttempts = 3 | ||
| val retryDelayMs = 1_500L |
Comment on lines
+113
to
+117
| val foregroundActivityCount = AtomicInteger(0) | ||
| registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks { | ||
| override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} | ||
| override fun onActivityStarted(activity: Activity) { | ||
| if (foregroundActivityCount.incrementAndGet() == 1) { |
Comment on lines
321
to
331
| // IMPORTANT: Initialize watched cache FIRST so fetchSeasonProgress() | ||
| // can read from the in-memory cache rather than falling back to a | ||
| // backend query that may return stale or empty data. The async below | ||
| // starts immediately, but initializeWatchedCache() runs synchronously | ||
| // before it, ensuring the cache is populated before fetchSeasonProgress | ||
| // checks getWatchedEpisodesFromCache(). | ||
| if (mediaType == MediaType.TV) { | ||
| runCatching { traktRepository.initializeWatchedCache() } | ||
| } | ||
| val seasonProgressDeferred = if (mediaType == MediaType.TV) { | ||
| async { fetchSeasonProgress(mediaId) } |
…e cache init inside async, use ProcessLifecycleOwner
| * to [saveAccountSyncPayload] via the delay, and the coordinator no longer adds | ||
| * its own outer retry loop (removed to avoid stacked backoff). | ||
| */ | ||
| suspend fun pushToCloud(): Result<Unit> = cloudSyncMutex.withLock { |
Comment on lines
+515
to
+516
| for (attempt in 1..maxAttempts) { | ||
| val result = authRepository.saveAccountSyncPayload(payload) |
| message = "push_save_attempt=${attempt}_failed_retrying", | ||
| severity = "warning" | ||
| ) | ||
| delay(retryDelayMs) |
Comment on lines
+126
to
+127
| runCatching { cloudSyncRepository.pushToCloud() } | ||
| .onFailure { android.util.Log.w("ArflixApp", "Foreground push retry failed: ${it.message}") } |
Comment on lines
+74
to
+75
| if (result.isFailure) { | ||
| Log.w(TAG, "Push failed after ${invalidation.scope}: ${result.exceptionOrNull()?.message}") |
| // causing watched badges to disappear and the season-watched revert bug. | ||
| runCatching { traktRepository.initializeWatchedCache() } | ||
|
|
||
| System.err.println("[CLOUD-SYNC] Full cloud restore applied successfully after cache re-init") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR addresses 6 root causes of cloud sync unreliability across the entire Arvio Cloud sync architecture, ensuring changes propagate reliably across devices in real time. Every cloud-related file has been audited end-to-end (12 files, 121 reference points).
Fix 1 — Cache re-initialization after cloud pull
File:
CloudSyncRepository.kt—applyCloudPayload()Problem:
clearAllProfileCaches()setscacheInitialized = false, causing watched badges to disappear until the next slow re-fetch from Supabase/Trakt APIs. Every cloud pull effectively wiped the watched badge cache.Fix: Immediately re-initialize the watched cache after clearing, so the next UI read (
fetchSeasonProgress,isEpisodeWatched, etc.) finds populated data.runCatching { traktRepository.initializeWatchedCache() }Fix 2 — Retry with backoff in CloudSyncCoordinator
File:
CloudSyncCoordinator.kt—scheduleFlush()Problem: A single network hiccup during push would mark dirty and wait up to 45s for the next periodic sync. Transient failures (DNS failover, TLS renegotiation) caused unnecessary delays.
Fix: After the initial debounce, retry up to 3 additional times with exponential backoff (0.5s → 2s → 6s). Only mark dirty if all retries are exhausted.
Fix 3 — Internal retry in
pushToCloud()File:
CloudSyncRepository.kt—pushToCloud()Problem: A single attempt on every push call. Network flakes caused permanent divergence until the user made another explicit change.
Fix: Up to 3 attempts with 1.5s gaps, building a fresh payload each attempt so concurrent state changes aren't lost. If all 3 fail,
isPushDirtyremainstruefor the periodic sync or foreground retry to pick up.Fix 4 — Foreground dirty-push retry
File:
ArflixApplication.ktProblem: If a push failed while the app was in background (e.g., user switched to another app mid-sync), the dirty flag wasn't retried until the 45s periodic sync tick.
Fix: Add
registerActivityLifecycleCallbackswith anAtomicIntegercounter to detect foreground transitions. On foreground, after a 500ms settle delay, retry any pending dirty push immediately.Fix 5 — Cross-device CW reappearing fix (CRITICAL)
File:
HomeViewModel.kt—watchHistoryEventscollectorProblem: When Device A removes a Continue Watching item, Device B receives the Supabase DELETE via WebSocket. But Device B's DataStore still has the old dismissed-CW set and local-CW cache. The CW re-resolution finds the item again because the cloud snapshot (with updated dismissed CW) was never pulled before the refresh.
Sequence of failure:
removeFromHistory()→ Supabase DELETE (broadcast via WebSocket to B) ANDpushToCloud()→ writes updated dismissed CW + local CW toaccount_sync_staterefreshContinueWatchingOnly()loadContinueWatchingFromHistoryStable()→ empty (item deleted) → falls back totraktRepository.getLocalContinueWatching()→ DataStore STILL has old data → item reappearsFix: Insert
cloudSyncRepository.pullFromCloud()beforerefreshContinueWatchingOnly()in thewatchHistoryEventscollector.runCatching { cloudSyncRepository.pullFromCloud() }.onSuccess { restoreResult -> if (restoreResult == CloudSyncRepository.RestoreResult.RESTORED) { loadHomeData() } } refreshContinueWatchingOnly(force = true)This ensures Device B's local state (dismissed CW set, local CW) matches Device A's before CW re-resolution. The 5s debounce in RealtimeSyncManager gives Device A's
pushToCloud()time to complete before this pull arrives.Fix 6 — Realtime WebSocket: account_sync INSERT subscription (AUDIT FINDING)
File:
RealtimeSyncManager.kt—joinChannel()Problem: The
account_syncchannel only subscribed toUPDATEevents.saveAccountSyncPayload()usesupsert(), which performs anINSERTwhen the row doesn't exist yet (first-ever push from a device). The WebSocket never fired forINSERToperations, meaning other devices had to wait up to 45s for the periodic sync to discover the very first push.Fix: Added an
INSERTevent filter alongside the existingUPDATEfilter.Audit Report — All 12 cloud-related files examined
CloudSyncRepository.ktCloudSyncCoordinator.ktArflixApplication.ktHomeViewModel.ktRealtimeSyncManager.ktAuthRepository.ktWatchHistoryRepository.ktTraktRepository.ktDetailsViewModel.ktpushToCloud()calls benefit from internal retryProfileViewModel.ktPlayerViewModel.ktSettingsViewModel.ktforceCloudSyncNowandrestoreCloudStateToLocalInternalcorrectTvViewModel.ktWatchlistViewModel.ktpullFromCloudon load,pushToCloudon operationsLoginViewModel.ktpullFromCloud+syncAddonsFromCloudon loginTesting Notes